/*
 * Copyright (C) 2010-2011 Comcast Cable.  All Rights Reserved.
 */
package com.hercules.android.receiver.handlers.commands.call;

import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;

import android.net.http.AndroidHttpClient;
import android.util.Base64;

import com.google.gjson.JSONObject;
import com.hercules.android.receiver.AndroidReceiver;
import com.hercules.android.receiver.LogUtil;
import com.hercules.android.receiver.handlers.commands.XRECommandHandlerException;
import com.hercules.android.receiver.handlers.commands.XREObjectCommandHandler;
import com.hercules.android.receiver.handlers.events.EventRaiser;
import com.hercules.android.receiver.handlers.events.EventRaisingException;
import com.hercules.android.receiver.handlers.events.XREGenerateEventCallCommandHandler;
import com.hercules.android.receiver.model.commands.XRECallCommand;
import com.hercules.android.receiver.model.commands.XRECommand;
import com.hercules.android.receiver.model.events.XREApplicationEvent;
import com.hercules.android.receiver.model.events.XREApplicationExecExternalResultEvent;
import com.hercules.android.receiver.model.events.XREApplicationHTTPErrorEvent;
import com.hercules.android.receiver.model.events.XREApplicationHTTPResponseEvent;
import com.hercules.android.receiver.model.events.XREApplicationReadLocalObjectEvent;
import com.hercules.android.receiver.model.events.XREErrorEvent;
import com.hercules.android.receiver.model.events.XREEvent;
import com.hercules.android.receiver.model.events.XREResourceEvent;
import com.hercules.android.receiver.model.events.XREVideoStatusEvent;
import com.hercules.android.receiver.model.objects.XREApplication;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This class represents the call command handler for a XREAppliction object. It extends from XREObjectCommandHandler.
 * It uses a ObjectPersistence implementation to save/read the application local object and an ObjectEncryptor
 * implementation to encrypt/decrypt the application local object. It also uses a XREGenerateEventCallCommandHandler
 * object to generate events on client side.
 * <p>
 * <b>Sample Usage:</b>
 *
 * <pre>
 * // create XREApplicationCallCommandHandler instance
 * XREApplicationCallCommandHandler handler = new XREApplicationCallCommandHandler();
 *
 * // set required fields
 * handler.setEventRaiser(new MockEventRaiser());
 * handler.setEventCallCommandHandler(new MockGenerateHandler());
 * handler.setEncryptor(new DefaultObjectEncryptor());
 * handler.setPersistence(new DefaultObjectPersistence());
 *
 * // prepare receiver
 * AndroidReceiver receiver = prepareAggregateReceiver(new XREApplication());
 *
 * // write application launch parameters
 * XRECallCommand command = prepareCommand(&quot;writeLocalObject&quot;);
 * handler.processCommandMessage(command, receiver);
 *
 * // write application launch parameters
 * command = prepareCommand(&quot;readLocalObject&quot;);
 * handler.processCommandMessage(command, receiver);
 *
 * // generate event for the application
 * command = prepareCommand(&quot;generateEvent&quot;);
 * handler.processCommandMessage(command, receiver);
 *
 * // execute external method
 * String targetClass = TargetClass.class.getName();
 * String targetMethod = &quot;targetMethod&quot;;
 * List&lt;?&gt; targetParameters = Arrays.asList(&quot;string&quot;, 1024);
 * command = prepareCommand(&quot;execExternal&quot;, targetClass, targetMethod, targetParameters, &quot;Call GUID&quot;);
 * handler.processCommandMessage(command, receiver);
 *
 * // execute http request
 * command = prepareCommand(&quot;httpRequest&quot;, &quot;id&quot;, &quot;GET&quot;, new String[0], &quot;http://www.topcoder.com:80/&quot;);
 * handler.processCommandMessage(command, receiver);
 * </pre>
 * <p>
 * <b>Thread Safety:</b> Not thread safe since it is mutable.
 *
 * @author DanLazar, orial
 * @version 1.0
 */
public class XREApplicationCallCommandHandler extends XREObjectCommandHandler {
    /** Represents default http port. */
    private static final int DEFAULT_PORT = 80;
    /** Represents regex pattern for URL. */
    private static final Pattern URL_PATTERN = Pattern.compile("^(?:https?://)?([^/:]*)(?::(\\d+))?(/?|/.*)$");

    /**
     * The persistence.
     * <p>
     * Has a setter and a getter. Cannot be null after the setter is called. Used in writeLocalObject and
     * readLocalObject methods.
     */
    private ObjectPersistence persistence;

    /**
     * The encryptor.
     * <p>
     * Has a setter and a getter. Cannot be null after the setter is called. Used in writeLocalObject and
     * readLocalObject methods.
     */
    private ObjectEncryptor encryptor;

    /**
     * The event call command handler.
     * <p>
     * Has a setter and a getter. Cannot be null after the setter is called. Used in generateEvent method.
     */
    private XREGenerateEventCallCommandHandler eventCallCommandHandler;

    /**
     * The event raiser for {@link XREVideoStatusEvent} event.
     * <p>
     * It has getter and setter. Cannot be null after set. Is used in readLocalObject, execExternal and httpRequest
     * methods.
     */
    private EventRaiser eventRaiser;

    /**
     * Default empty constructor.
     */
    public XREApplicationCallCommandHandler() {
        // empty
    }

    /**
     * Gets the persistence.
     *
     * @return the persistence
     */
    public ObjectPersistence getPersistence() {
        return persistence;
    }

    /**
     * Sets the persistence.
     *
     * @param persistence the persistence
     * @throws IllegalArgumentException if parameter is null
     */
    public void setPersistence(ObjectPersistence persistence) {
        // check argument
        Helper.checkNotNull(persistence, "persistence");

        this.persistence = persistence;
    }

    /**
     * Gets the encryptor.
     *
     * @return the encryptor
     */
    public ObjectEncryptor getEncryptor() {
        return encryptor;
    }

    /**
     * Sets the encryptor.
     *
     * @param encryptor the encryptor
     * @throws IllegalArgumentException if parameter is null
     */
    public void setEncryptor(ObjectEncryptor encryptor) {
        // check argument
        Helper.checkNotNull(encryptor, "encryptor");

        this.encryptor = encryptor;
    }

    /**
     * Gets the event call command handler.
     *
     * @return the event call command handler
     */
    public XREGenerateEventCallCommandHandler getEventCallCommandHandler() {
        return eventCallCommandHandler;
    }

    /**
     * Sets the event call command handler.
     *
     * @param eventCallCommandHandler the event call command handler
     * @throws IllegalArgumentException if parameter is null
     */
    public void setEventCallCommandHandler(XREGenerateEventCallCommandHandler eventCallCommandHandler) {
        // check argument
        Helper.checkNotNull(eventCallCommandHandler, "eventCallCommandHandler");

        this.eventCallCommandHandler = eventCallCommandHandler;
    }

    /**
     * Gets the event raiser for {@link XREVideoStatusEvent} event.
     *
     * @return the event raiser for {@link XREVideoStatusEvent} event
     */
    public EventRaiser getEventRaiser() {
        return eventRaiser;
    }

    /**
     * Sets the event raiser for {@link XREVideoStatusEvent} event.
     *
     * @param eventRaiser the event raiser for {@link XREVideoStatusEvent} event
     * @throws IllegalArgumentException if parameter is null
     */
    public void setEventRaiser(EventRaiser eventRaiser) {
        // check argument
        Helper.checkNotNull(eventRaiser, "eventRaiser");

        this.eventRaiser = eventRaiser;
    }

    /**
     * Process the given command.
     *
     * @param command the XRE command to be handled
     * @param receiver the receiver for the XRE application
     * @throws IllegalArgumentException if given command or receiver is null
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any error occurs while handling the command
     */
    @SuppressWarnings("unchecked")
    @Override
    public void processCommandMessage(XRECommand command, AndroidReceiver receiver)
        throws XREApplicationCallCommandHandlerException {
        String methodName = "processCommandMessage";
        Helper.logEnter(getLogger(), this, methodName, command, receiver);

        try {
            // check arguments and get target object
            XRECallCommand callCommand = Helper.checkCommand(command);
            XREApplication object = Helper.checkReceiver(receiver, callCommand, XREApplication.class);

            // get command method
            String method = callCommand.getMethod();

            int index = 0;
            if ("writeLocalObject".equals(method)) {
                Map<String, Object> params = Helper.getParam(callCommand, 0, Map.class);
                params.putAll(object.getLaunchParams());

                // write all launch params
                for (Entry<String, Object> entry : params.entrySet()) {
                    writeLocalObject(receiver, entry.getKey(), (Serializable) entry.getValue());
                }
            } else if ("readLocalObject".equals(method)) {
                // read local objects
                readLocalObject(receiver, object);
                // AndroidAssembly3 added support for generateAppEvent
            } else if ("generateEvent".equals(method) || "generateAppEvent".equals(method)) {
                generateEvent(receiver, command);
            } else if ("execExternal".equals(method)) {
                // get command parameters
                String targetClass = getStringParam(callCommand, index++);
                String targetMethod = getStringParam(callCommand, index++);
                List<Object> methodParams = Helper.getParam(callCommand, index++, List.class);
                String callGUID = getStringParam(callCommand, index);

                // execute target method
                execExternal(receiver, object, targetClass, targetMethod, methodParams, callGUID);
            } else if ("httpRequest".equals(method)) {
                // get command parameters
                String requestId = getStringParam(callCommand, index++);
                String httpVerb = getStringParam(callCommand, index++);
                Map<String, String> headers = Helper.getParam(callCommand, index++, Map.class);
                String url = getStringParam(callCommand, index++);
                // get post data param only if we have POST http verb
                String postdata = "POST".equals(httpVerb) ? getStringParam(callCommand, index) : null;

                // process http request
                httpRequest(receiver, object, requestId, httpVerb, headers, url, postdata);
            } else if ("log".equals(method)) {
            	String logContent = getStringParam(callCommand, index++);
            	// log the content
            } else {
                throw new XREApplicationCallCommandHandlerException("Call command method '" + method
                    + "' is not supported.");
            }

            Helper.logExit(getLogger(), this, methodName);
        } catch (IllegalArgumentException e) {
            Helper.logError(getLogger(), e, methodName, e);
        } catch (IllegalStateException e) {
            Helper.logError(getLogger(), e, methodName, e);
        } catch (XREApplicationCallCommandHandlerException e) {
            Helper.logError(getLogger(), e, methodName, e);
        }
    }

    /**
     * Writes the given object and its key to the persistent storage. Before writing to persistence the key and object
     * will be encrypted.
     *
     * @param receiver the receiver
     * @param key object key
     * @param object the object
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any other error occurs
     */
    private void writeLocalObject(AndroidReceiver receiver, String key, Serializable object)
        throws XREApplicationCallCommandHandlerException {
        // check state
        Helper.checkState(encryptor, "encryptor");
        Helper.checkState(persistence, "persistence");

        try {
            // create encrypted object
            EncryptedObject encryptedObject = new EncryptedObject();

            // encrypt key
            encryptedObject.setEncryptedKey(encryptor.encrypt(key, receiver));
            // encrypt object
            encryptedObject.setEncryptedObject(encryptor.encrypt(object, receiver));

            // save to persistence
            persistence.writeObject(encryptedObject, receiver);
        } catch (ObjectPersistenceException e) {
            throw new XREApplicationCallCommandHandlerException(
                "Persistence error occurred while writing encrypted object.", e);
        } catch (ObjectEncryptorException e) {
            throw new XREApplicationCallCommandHandlerException("Encryption error occurred while "
                + "encrypting data, key = '" + key + "', object = '" + object + "'.", e);
        }
    }

    /**
     * This method reads the application local object from persistent storage and then raises an
     * {@link XREApplicationEvent}.
     * <p>
     * AndroidAssembly2 added application parameter
     *
     * @param receiver the receiver
     * @param application the current {@link XREApplication}
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any error occurs
     */
    private void readLocalObject(AndroidReceiver receiver, XREApplication application)
        throws XREApplicationCallCommandHandlerException {
        // check state
        Helper.checkState(encryptor, "encryptor");
        Helper.checkState(persistence, "persistence");
        Helper.checkState(eventRaiser, "eventRaiser");

        try {
            // get local objects from persistence
            List<EncryptedObject> encryptedObjects = persistence.readObjects(receiver);

            // create a object's map
            Map<String, Object> objectsMap = new HashMap<String, Object>();

            for (EncryptedObject encryptedObject : encryptedObjects) {
                // decrypt key
                Serializable decryptedKey = encryptor.decrypt(encryptedObject.getEncryptedKey(), receiver);
                if (!(decryptedKey instanceof String)) {
                    throw new XREApplicationCallCommandHandlerException("Decrypted key '" + decryptedKey
                        + "'is not instance of String.");
                }
                String key = (String) decryptedKey;

                // decrypt object
                Object object = encryptor.decrypt(encryptedObject.getEncryptedObject(), receiver);

                // put to objects map
                objectsMap.put(key, object);
            }

            // create a event
            XREApplicationReadLocalObjectEvent event = new XREApplicationReadLocalObjectEvent();
            event.setName(XREApplicationReadLocalObjectEvent.ON_READ_LOCAL_OBJECT_EVENT_NAME);
            event.setLocalObject(objectsMap);
            event.setSource(application.getId());
            event.setHandler(application.getId());

            // raise event
            eventRaiser.raiseEvent(event, receiver, false);
        } catch (EventRaisingException e) {
            throw new XREApplicationCallCommandHandlerException(
                "Error occurred while raising 'onReadLocalObject' event.", e);
        } catch (ObjectEncryptorException e) {
            throw new XREApplicationCallCommandHandlerException(
                "Encryption error occurred while decrypting encrypted objects.", e);
        } catch (ObjectPersistenceException e) {
            throw new XREApplicationCallCommandHandlerException(
                "Persistence error occurred while fetching objects from persistence.", e);
        }
    }

    /**
     * This method will generate an event on the client side.
     *
     * @param receiver the receiver
     * @param command the command
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any other error occurs
     */
    private void generateEvent(AndroidReceiver receiver, XRECommand command)
        throws XREApplicationCallCommandHandlerException {
        // check state
        Helper.checkState(eventCallCommandHandler, "eventCallCommandHandler");

        try {
            // process with generate event command handler
            eventCallCommandHandler.onCommandMessage(command, receiver);
        } catch (XRECommandHandlerException e) {
            throw new XREApplicationCallCommandHandlerException("Error occurred while processing command "
                + "with 'generateEvent' method by corresponding command handler.", e);
        }
    }

    /**
     * Instructs the receiver to execute the specified method on the specified target in its container, or hosting
     * application. The callGUID is a unique GUID used to identify this call. This method will generate a corresponding
     * execExternalResult event that also contains the callGUID.
     *
     * @param receiver the receiver
     * @param application the xre application
     * @param target the target on which the method will be executed
     * @param methodName the method to be executed
     * @param params the method parameters
     * @param callGUID the call guid
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any other error occurs
     */
    private void execExternal(AndroidReceiver receiver, XREApplication application, String target, String methodName,
        List<Object> params, String callGUID) throws XREApplicationCallCommandHandlerException {
        // check state
        Helper.checkState(eventRaiser, "eventRaiser");

        try {
            try {
                // create object from target
                Class<?> classDefinition = Class.forName(target);
                Object object = classDefinition.getConstructor().newInstance();

                // invoke the method on object
                Object methodResult = null;
                boolean found = false;
                for (Method mtd : classDefinition.getDeclaredMethods()) {
                    if (methodName.equals(mtd.getName()) && mtd.getParameterTypes().length == params.size()) {
                        // invoke method
                        methodResult = mtd.invoke(object, params.toArray());

                        found = true;
                        break;
                    }
                }
                if (!found) {
                    throw new NoSuchMethodException("Method '" + methodName + "' was not found.");
                }

                // create event
                XREApplicationExecExternalResultEvent event = new XREApplicationExecExternalResultEvent();
                event.setName(XREApplicationExecExternalResultEvent.ON_EXEC_EXTERNAL_RESULT_EVENT_NAME);
                event.setValue(String.valueOf(methodResult));
                event.setCallGUID(callGUID);
                event.setSource(application.getId());

                // raise event
                eventRaiser.raiseEvent(event, receiver, false);
            } catch (LinkageError e) {
                throw new XREApplicationCallCommandHandlerException("Linkage failed for '" + target + "' class.", e);
            } catch (SecurityException e) {
                throw new XREApplicationCallCommandHandlerException("Security manager denies access to members of '"
                    + target + "' class.", e);
            } catch (ClassNotFoundException e) {
                throw new XREApplicationCallCommandHandlerException("Class '" + target + "' was not found.", e);
            } catch (NoSuchMethodException e) {
                throw new XREApplicationCallCommandHandlerException("Class '" + target
                    + "' do not have default constructor or method with name '" + methodName + "'.", e);
            } catch (InstantiationException e) {
                throw new XREApplicationCallCommandHandlerException("Class '" + target + "' cannot be instantiated.", e);
            } catch (IllegalAccessException e) {
                throw new XREApplicationCallCommandHandlerException("Default constructor or method '" + methodName
                    + "' of class '" + target + "' are not accessible.", e);
            } catch (IllegalArgumentException e) {
                throw new XREApplicationCallCommandHandlerException("Method '" + methodName + "' of class '" + target
                    + "' cannot be executed for given parameter list.", e);
            } catch (InvocationTargetException e) {
                throw new XREApplicationCallCommandHandlerException("Default constructor or method '" + methodName
                    + "' of class '" + target + "' has thrown exception during invocation.", e);
            }
        } catch (XREApplicationCallCommandHandlerException e) {
            LogUtil.info(getLogger(), this, methodName,
                "Error occurred while processing method 'execExternal', exception message: {0}.", e.getMessage());

            // generate onError event
            XREErrorEvent event = new XREErrorEvent();
            event.setName(XREErrorEvent.ON_ERROR_EVENT_NAME);
            event.setSource(application.getId());
            event.setErrorType(XREErrorEvent.ERROR_LOAD_FAILED_TYPE);
            event.setDescription(e.getMessage());

            try {
                // raise event
                eventRaiser.raiseEvent(event, receiver, false);
            } catch (EventRaisingException eventRaisingException) {
                throw new XREApplicationCallCommandHandlerException("Error occurred while raising 'onError' event.",
                    eventRaisingException);
            }
        } catch (EventRaisingException e) {
            throw new XREApplicationCallCommandHandlerException(
                "Error occurred while raising 'onExecExternalResult' event.", e);
        }
    }

    /**
     * Instructs the receiver to generate an HTTP request with the specified properties and dispatch the result to the
     * server using the onHTTPResponse event. If an error occurs, the receiver dispatches onHTTPError. The requestId
     * property is an application-defined identifier for the request. The httpVerb parameter can be 'GET' or 'POST'. The
     * postData parameter is only valid when the httpVerb is 'POST'.
     *
     * @param receiver the receiver
     * @param object the target {@link XREApplication}
     * @param requestId the request id
     * @param httpVerb the http verb (GET or POST)
     * @param headers the headers
     * @param url the url
     * @param postData the post data (valid only if httpVerb is 'POST')
     * @throws IllegalArgumentException if command parameters are not correct
     * @throws IllegalStateException if required fields are not set
     * @throws XREApplicationCallCommandHandlerException if any other error occurs
     */
    private void httpRequest(AndroidReceiver receiver, XREApplication object, String requestId, String httpVerb,
        Map<String, String> headers, String url, String postData) throws XREApplicationCallCommandHandlerException {
        // check state
        Helper.checkState(eventRaiser, "eventRaiser");

        try {
            // check url
            Matcher urlMathcer = URL_PATTERN.matcher(url);
            if (!urlMathcer.find()) {
                throw new IllegalArgumentException("Url '" + url + "' in command parameter #3 has incorrect format. "
                    + "Expected '" + URL_PATTERN.pattern() + "' regex pattern.");
            }

            // create http host
            int port = urlMathcer.group(2) == null ? DEFAULT_PORT : Integer.parseInt(urlMathcer.group(2));
            HttpHost host = new HttpHost(urlMathcer.group(1), port);

            // prepare http request
            HttpRequest request = prepareRequest(httpVerb, headers, url, postData);

            // prepare http client
            AndroidHttpClient client = AndroidHttpClient.newInstance("call_command_handlers");

            try {
                // execute request
                HttpResponse response = client.execute(host, request);

                // create event
                XREApplicationHTTPResponseEvent event = new XREApplicationHTTPResponseEvent();
                event.setSource(object.getId());
                event.setHandler(object.getId());
                event.setName(XREApplicationHTTPResponseEvent.ON_HTTP_RESPONSE_EVENT_NAME);
                event.setStatus(response.getStatusLine().getStatusCode());

                // get response if it is available
                if (response.getEntity() != null) {
                    // event.setContentType(response.getEntity().getContentType().getValue());
                    event.setContentType("text/plain");

                    // read body
                    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
                    if (response.getEntity() != null) {
                        response.getEntity().writeTo(bytes);
                    }
                    event.setBody(Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP));
                }

                // raise event
                eventRaiser.raiseEvent(event, receiver, false);
            } finally {
                client.close();
            }
        } catch (EventRaisingException e) {
            throw new XREApplicationCallCommandHandlerException("Error occurred while raising 'onHTTPResponse' event.",
                e);
        } catch (IOException e) {
            // create event
            XREEvent event = new XREApplicationHTTPErrorEvent();
            event.setName(XREApplicationHTTPErrorEvent.ON_HTTP_ERROR_EVENT_NAME);
            event.setSource(object.getId());

            try {
                // raise event
                eventRaiser.raiseEvent(event, receiver, false);
            } catch (EventRaisingException eventRaisingException) {
                throw new XREApplicationCallCommandHandlerException(
                    "Error occurred while raising 'onHTTPError' event.", eventRaisingException);
            }

            LogUtil.info(getLogger(), this, "httpRequest", "I/O error occurred while processing http request: {0}", e
                .getMessage());
        }
    }

    /**
     * Prepares http request.
     * <p>
     * AndroidAssembly2 changed headers to Map
     *
     * @param httpVerb the http verb (GET or POST)
     * @param headers the headers
     * @param url the url
     * @param postData the post data (valid only if httpVerb is 'POST')
     * @return the http request
     * @throws UnsupportedEncodingException if system default encoding is not supported
     * @throws IllegalArgumentException if parameters are invalid
     */
    private static HttpRequest prepareRequest(String httpVerb, Map<String, String> headers, String url, String postData)
        throws UnsupportedEncodingException {
        HttpRequest request;
        if ("POST".equals(httpVerb)) {
            request = new HttpPost(url);

            // set post data
            StringEntity entity = new StringEntity(postData);
            ((HttpPost) request).setEntity(entity);
        } else if ("GET".equals(httpVerb)) {
            request = new HttpGet(url);
        } else {
            throw new IllegalArgumentException("Command parameter #1, 'httpVerb' can be only 'GET' or 'POST'.");
        }

        // fill headers
        for (Entry<String, String> entry : headers.entrySet()) {
            request.addHeader(entry.getKey(), entry.getValue());
        }
        return request;
    }

    /**
     * Gets string command parameter.
     *
     * @param callCommand the call command
     * @param index the parameter index
     * @return the command parameter
     * @throws IllegalArgumentException if command parameter is not correct
     */
    private static String getStringParam(XRECallCommand callCommand, int index) {
        String paramValue = Helper.getParam(callCommand, index, String.class);

        // check empty string
        if (paramValue.trim().length() == 0) {
            throw new IllegalArgumentException("String command parameter #" + index + " should be not empty.");
        }

        return paramValue;
    }
}